25 — 真实感渲染

Three.js Journey · Bruno Simon · 难度:简单

25 — 真实感渲染 课程海报

简介

几节课前我们导入汉堡时,它看起来并不太好。简而言之:有很多因素都会导致模型看起来不对劲。

有时我们想要非常真实的渲染效果。也许是因为我们想在网站上展示一件真实的产品,或者我们是 3D 艺术家,想用尽可能最好的效果来展示自己的作品。无论如何,我们都需要一个尽可能真实的渲染结果。

在本节课中,我们将学习许多提升模型渲染质量的技术。不过要小心:其中一些技术会影响性能,还有一些技术取决于你的模型。你需要根据具体情况灵活调整。

搭建环境

我们可以使用自己的汉堡,但最好尝试一个更真实的模型,带有 texture、normal map 等。我们将使用 Flight Helmet,它来自 GLTF Sample Models 仓库。你可以在 /static/models/ 文件夹中找到该模型,它已经被加载并添加到 scene 中。

我们还会使用 lil-gui 来调整尽可能多的参数。如果我们想创造出完美的渲染效果,这是必需的。

环境贴图

我们还设置了一个环境贴图(environment map),并使用 HDR 等距柱状投影(equirectangular)texture。这已经让 scene 拥有了不错的光照和反射效果。

我们不会再次讲解这个主题,因为已经有一整节课专门讨论过它。只需记住:HDR 环境能带来更好的光照,但代价是性能开销和文件体积增大。

尽量使用尽可能小的 texture;如果不把环境贴图用作背景,你甚至可以把分辨率降得很低,用户也不会察觉。

现在让我们来谈谈 WebGLRenderer 以及你需要了解的一些属性。

色调映射

色调映射(tone mapping)旨在将高动态范围(HDR)值转换为低动态范围(LDR)值。

虽然这里说的 HDR 与环境贴图中的 HDR 是同一个概念,但 Three.js 中的 tone mapping 实际上会伪造从 LDR 到 HDR 的转换过程——即使颜色本身并非 HDR,也能得到非常真实的渲染效果。

要更改色调映射,请更新 WebGLRenderer 上的 toneMapping 属性。

有多个可选值:

让我们从 THREE.ACESFilmicToneMapping 开始:

// Tone mapping
renderer.toneMapping = THREE.ACESFilmicToneMapping

为了更直观地感受差异,让我们把 toneMapping 加到 lil-gui 中。我们可以通过向 gui.add(...) 的第三个参数传入一个包含不同键和值的对象来创建一个下拉菜单:

gui.add(renderer, 'toneMapping', {
    No: THREE.NoToneMapping,
    Linear: THREE.LinearToneMapping,
    Reinhard: THREE.ReinhardToneMapping,
    Cineon: THREE.CineonToneMapping,
    ACESFilmic: THREE.ACESFilmicToneMapping
})
lesson demo video preview

让我们试试 THREE.ReinhardToneMapping:

renderer.toneMapping = THREE.ReinhardToneMapping

虽然这种色调映射可能会给人色彩发白的感觉,但它实际上能达到很高的真实感,就像一台设置不当的相机拍出的效果。

我们可以灵活地调整色调映射曝光(tone mapping exposure),从而影响进入 scene 的光量,算法会相应地做出调整。要修改这个值,我们只需直接更新 renderer 上的 toneMappingExposure 属性:

renderer.toneMappingExposure = 2

我们也把它加到 lil-gui 中:

gui.add(renderer, 'toneMappingExposure').min(0).max(10).step(0.001)
lesson demo video preview

目前我们先把 toneMappingExposure 设为 3:

renderer.toneMappingExposure = 3

抗锯齿

我们把在某些情况下出现的、通常位于 geometry 边缘的阶梯状瑕疵称为锯齿(aliasing)。

我们的模型不会出现这个问题,因为它有很多细节。但如果你的屏幕像素比为 1,观察边缘——尤其是明亮的边缘——慢慢旋转 camera,你可能会看到这个问题:

这是一个众所周知的问题。当渲染一个像素时,它会测试该像素中渲染的是哪个 geometry,计算颜色,最终该颜色显示在屏幕上。

但 geometry 的边缘通常无法与屏幕像素的垂直线和水平线完美对齐,这就是为什么你会得到这种名为 aliasing 的阶梯状瑕疵。

有许多方法可以解决这个问题,开发者们多年来一直在与之斗争。

一个简单的解决方案是把 renderer 的分辨率提高一倍。当缩回正常尺寸时,每个像素的颜色会自动由渲染出的 4 个像素取平均得到。

这种方案称为 super sampling(SSAA)或 fullscreen sampling(FSAA),它是最简单、最高效的一种。遗憾的是,这意味着要多渲染 4 倍的像素,可能会导致性能问题。

另一种方案称为 multi sampling(MSAA)。同样,它的思路是像 super sampling 那样为每个像素渲染多个值(通常是 4 个),但只在 geometry 的边缘进行。然后将这些像素值取平均得到最终的像素颜色。

最新的 GPU 可以执行这种 multi sampling 抗锯齿,Three.js 会自动处理相关设置。我们只需在实例化时(而不是之后)将 antialias 属性设为 true:

const renderer = new THREE.WebGLRenderer({
    canvas: canvas,
    antialias: true
})

现在这些锯齿瑕疵应该消失了。

启用 antialias 会消耗一些资源。正如之前所说,像素比高于 1 的屏幕其实并不太需要 antialias。一个正确的做法是只在像素比低于 2 的屏幕上启用它。我们将在以后的课程中学习如何做到这一点,以及其他优化技巧。

阴影

对真实感渲染来说,一个重要补充就是添加阴影。由于环境贴图就像来自四面八方的光,它无法投射阴影。

我们需要添加一个大致与环境贴图光照相匹配的 light,并用它来投射阴影。

平行光

我们将使用一个 DirectionalLight,暂时随机放置它的位置:

/**
 * Directional light
 */
const directionalLight = new THREE.DirectionalLight('#ffffff', 6)
directionalLight.position.set(3, 7, 6)
scene.add(directionalLight)

然后把它 position 的各个分量加到 lil-gui 中:

gui.add(directionalLight, 'intensity').min(0).max(10).step(0.001).name('lightIntensity')
gui.add(directionalLight.position, 'x').min(- 10).max(10).step(0.001).name('lightX')
gui.add(directionalLight.position, 'y').min(- 10).max(10).step(0.001).name('lightY')
gui.add(directionalLight.position, 'z').min(- 10).max(10).step(0.001).name('lightZ')
lesson demo video preview

这会让找到与环境贴图相匹配的位置容易得多,我们稍后就会这么做。

启用阴影

现在我们有了可以投射阴影的 light,在 WebGLRenderer 实例上开启阴影。然后,像我们在 Shadows 课程中那样,将 shadow 类型改为 THREE.PCFSoftShadowMap:

// Shadows
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap

在 DirectionalLight 上启用它,并把它加到 lil-gui 中:

// Shadows
directionalLight.castShadow = true
gui.add(directionalLight, 'castShadow')

为 directionalLight.shadow.camera 添加一个 CameraHelper:

// Helper
const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(directionalLightCameraHelper)
lesson demo video preview

如你所见,shadow camera 覆盖的区域并没有正确地包住模型。这是因为 directional light 默认指向 scene 的中心,而这里对应的是模型的最低处。

我们需要把那个 target 向上移动,这可以通过 directionalLight.target 属性来实现,它是一个 Object3D,也就是说它有一个 position 属性(就像一个 Mesh):

// Target
directionalLight.target.position.set(0, 4, 0)

没有变化?如果你还记得上一节课的内容,Three.js 使用矩阵(matrix)来定义物体的变换。当我们更改 position、rotation 和 scale 这类属性时,它们会被编译进一个 matrix,但这个编译过程会在物体即将被渲染前才进行,而且只有物体在 scene 中时才会执行。虽然 directionalLight 在 scene 中,但它的 target 并不在。

我们有两个选择:

我们选择手动更新,在更改 target 的 position 之后立即对它调用 updateWorldMatrix():

// Target
directionalLight.target.position.set(0, 4, 0)
directionalLight.target.updateWorldMatrix()

现在 shadow camera helper 已经贴合 scene,我们可以减小它的 far 值:

directionalLight.shadow.camera.far = 15

由于我们想要真实而精确的阴影,而且只有一个 light,我们可以把 shadow map 的尺寸提高到 1024x1024,而不必担心帧率下降。

directionalLight.shadow.mapSize.set(1024, 1024)

现在我们可以为模型的所有 Mesh 启用阴影。

我们有一个名为 updateAllMaterials 的函数,它会在模型加载时被调用。这个函数使用 scene 上的 traverse() 方法遍历每个 material,并判断它是否是 Mesh:

const updateAllMaterials = () =>
{
    scene.traverse((child) =>
    {
        if(child.isMesh)
        {
            // Activate shadow here
        }
    })
}

在这里,我们可以简单地为所有子对象同时启用 castShadow 和 receiveShadow:

const updateAllMaterials = () =>
{
    scene.traverse((child) =>
    {
        if(child.isMesh)
        {
            // ...

            child.castShadow = true
            child.receiveShadow = true
        }
    })
}
lesson demo video preview

现在你应该能看到准确的阴影了,主要出现在木质底座上和模型内部。

既然我们同时能看到 helper 和阴影,就可以调整 light 的 position 和 intensity,以找到完美的设置:

lesson demo video preview
directionalLight.position.set(- 4, 6.5, 2.5)

移除或注释掉 helper:

// // Helper
// const directionalLightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
// scene.add(directionalLightCameraHelper)

另外注意,虽然我们通过把 mapSize 设为 1024 获得了锐利的阴影,但更低的分辨率会产生模糊的阴影——如果 scene 中有足够的细节,它看起来也不会太糟,而且实际上会提升性能:

directionalLight.shadow.mapSize.set(512, 512)

Texture 与颜色空间

让我们添加一个地板和一堵后墙。这里的目标是使用 texture 并发现一个非常具体的问题,同时也是练习的机会。

如果我们想要真实的效果,选择合适的 texture 非常重要。网上有很多提供优秀 texture 的资源,但 Poly Haven 最近新增的 Textures 板块提供了很棒的 texture,而且我们可以精确选择要导出哪些文件。

为了节省时间,/static/textures/ 中已经提供了两套 texture,但你也可以随意使用自己的。导出设置看起来是这样的:

如果你想自己练习,现在是个好机会。使用 wood_cabinet_worn_long/ texture 在模型正下方创建一个 8x8 的平面,并使用 castle_brick_broken_06/ texture 在模型后方创建一个 8x8 的平面。

首先我们需要实例化 TextureLoader:

/**
 * Loaders
 */
// ...
const textureLoader = new THREE.TextureLoader()

接下来,我们需要加载地板的 texture:

/**
 * Floor
 */
const floorColorTexture = textureLoader.load('/textures/wood_cabinet_worn_long/wood_cabinet_worn_long_diff_1k.jpg')
const floorNormalTexture = textureLoader.load('/textures/wood_cabinet_worn_long/wood_cabinet_worn_long_nor_gl_1k.png')
const floorAORoughnessMetalnessTexture = textureLoader.load('/textures/wood_cabinet_worn_long/wood_cabinet_worn_long_arm_1k.jpg')

然后我们可以使用 PlaneGeometry 和一个 MeshStandardMaterial 来创建 Mesh,并使用我们加载的所有 texture:

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(8, 8),
    new THREE.MeshStandardMaterial({
        map: floorColorTexture,
        normalMap: floorNormalTexture,
        aoMap: floorAORoughnessMetalnessTexture,
        roughnessMap: floorAORoughnessMetalnessTexture,
        metalnessMap: floorAORoughnessMetalnessTexture,
    })
)
scene.add(floor)

最后,我们旋转它:

floor.rotation.x = - Math.PI * 0.5

我们对墙也做同样的处理:

/**
 * Wall
 */
const wallColorTexture = textureLoader.load('/textures/castle_brick_broken_06/castle_brick_broken_06_diff_1k.jpg')
const wallNormalTexture = textureLoader.load('/textures/castle_brick_broken_06/castle_brick_broken_06_nor_gl_1k.png')
const wallAORoughnessMetalnessTexture = textureLoader.load('/textures/castle_brick_broken_06/castle_brick_broken_06_arm_1k.jpg')

const wall = new THREE.Mesh(
    new THREE.PlaneGeometry(8, 8),
    new THREE.MeshStandardMaterial({
        map: wallColorTexture,
        normalMap: wallNormalTexture,
        aoMap: wallAORoughnessMetalnessTexture,
        roughnessMap: wallAORoughnessMetalnessTexture,
        metalnessMap: wallAORoughnessMetalnessTexture,
    })
)
wall.position.y = 4
wall.position.z = - 4
scene.add(wall)

尽管我们的 scene 相当明亮,但可以看到 texture 看起来白得很奇怪。这是由于我们所说的“颜色空间”(color space)造成的。

颜色空间是一种根据人眼敏感度来优化颜色存储方式的方法。这主要涉及那些会被看到的 texture(在我们的例子中是 bricksColorTexture)。

作为信息使用的 texture,例如 bricksAmbientOcclusionTexture、bricksNormalTexture 和 bricksRoughnessTexture,则应该使用线性(linear)颜色进行编码。

默认情况下,Three.js 将颜色空间设为 linear,这意味着我们只需修改 floorColorTexture 和 wallColorTexture 这两个 texture,把它们的 colorSpace 设为 THREE.SRGBColorSpace:

floorColorTexture.colorSpace = THREE.SRGBColorSpace

// ...

wallColorTexture.colorSpace = THREE.SRGBColorSpace

好多了。

但你可能会疑惑,为什么我们对模型不需要这样做。原因是这些信息已经在 GLTF 文件内部设置好了,Three.js 知道该对 texture 使用哪种颜色空间。

最终微调

现在一切就绪,我们可以微调各项数值,确保 directionalLight 与环境贴图中的光相匹配,尝试其他环境贴图,调整阴影,测试不同的色调映射,等等。

这由你决定。慢慢来,暂时把视线从渲染结果上移开,看看周围,因为你需要现实生活中的参照物,确保屏幕颜色正常,也许可以把作品展示给朋友以获得外部的视角,直到一切都调校正确。

汉堡

让我们用汉堡来试试。一个版本已经放在 /static/models/hamburger.glb 中。

这个文件没有经过 Draco 压缩。如果你使用自己的模型,请确保它没有被压缩,或者像我们在 Imported Model 课程中那样,把 DRACOLoader 添加到 GLTFLoader。

注释掉 Helmet 部分(这样必要时我们可以把它放回来):

// // Helmet
// gltfLoader.load(
//     '/models/FlightHelmet/glTF/FlightHelmet.gltf',
//     (gltf) =>
//     {
//         gltf.scene.scale.set(10, 10, 10)
//         scene.add(gltf.scene)

//         updateAllMaterials()
//     }
// )

用同样的方式加载位于 /models/hamburger.glb 的汉堡,并使用下面的 scale 和 position:

gltfLoader.load(
    '/models/hamburger.glb',
    (gltf) =>
    {
        gltf.scene.scale.set(0.4, 0.4, 0.4)
        gltf.scene.position.set(0, 2.5, 0)
        scene.add(gltf.scene)

        updateAllMaterials()
    }
)

你的汉堡出现了,但表面被一些讨厌的条纹覆盖。

把 envMapIntensity 调低可以更清楚地看到它:

scene.environmentIntensity = 0

不,我们并没有让汉堡在烤架上烤焦。

这些瑕疵叫做“阴影痤疮”(shadow acne)。在计算表面是否处于阴影中时,由于精度原因,阴影痤疮既可能出现在平滑表面上,也可能出现在平坦表面上。这里发生的情况是汉堡在自己表面上投射了阴影。

我们必须调整 light 的 shadow 的 bias 和 normalBias 属性来修复这个阴影痤疮。

bias 通常对平坦表面有帮助。

normalBias 通常对圆润表面有帮助。

解决这个问题就是要为这两个设置找到合适的值,而有什么比用 lil-gui 来找这些完美值更好的呢

gui.add(directionalLight.shadow, 'normalBias').min(- 0.05).max(0.05).step(0.001)
gui.add(directionalLight.shadow, 'bias').min(- 0.05).max(0.05).step(0.001)
lesson demo video preview

在我们的例子中,我们可以将 normalBias 定为 0.025,bias 定为 -0.004:

directionalLight.shadow.normalBias = 0.027
directionalLight.shadow.bias = - 0.004

现在你得到一个相当不错、没有痤疮的汉堡,我们可以把 envMapIntensity 重新设为 1:

scene.environmentIntensity = 1

祝你用餐愉快。

更进一步

本节课中我们学到的技术都是能为你打下良好基础的传统技术,但还有大量其他技术取决于你想要实现的效果,比如环境光遮蔽(ambient occlusion)、景深(depth of field)、运动模糊(motion blur)、泛光(bloom)等。

这些技术大多需要使用 post-processing,你将在后面的一节课中学到。

另外注意,我们一直在使用 HDR 环境贴图和 texture,并没有太在意性能,因为我们要专注于真实感,但要始终关注性能。我们将在以后的课程中讨论优化。